HTMLのaタグとimgタグをMarkdown記法に変換するシェルスクリプトを書いてみた

HTMLのaタグとimgタグをMarkdown記法に変換するシェルスクリプトを書いてみた

Clock Icon2024.07.14

HTMLのタグをMarkdown記法に変換したい

こんにちは、のんピ(@non____97)です。

皆さんはHTMLのタグをMarkdown記法に変更したいなと思ったことはありますか? 私はあります。

私は基本的にMarkdown記法をベースに記事を書いています。しかし、一部直接HTMLのaタグやimgタグを使っている場面があります。そんな折、Zenn記法に記事を変換する必要が出てきました。

Zennでは直接HTMLタグを使用することはできません。ZennのMarkdown記法は以下をご覧ください。

https://zenn.dev/zenn/articles/markdown-guide

HTMLをMarkdownに変換するだけであればPandocを使用すれば良いと思います。Pandocの紹介と使い方は以下記事とユーザーガイドをご覧ください。

https://dev.classmethod.jp/articles/pandoc-markdown2html/

https://pandoc-doc-ja.readthedocs.io/ja/latest/users-guide.html

今回はZenn固有の記法もあるため、Pandocではなくsedとawkで色々カスタマイズを頑張ってみたくなってきました。

ということでやってみます。

やってみた

用意したスクリプト

使い方

使い方は以下のとおりです。変換したいファイル名と変換後のファイルに付与するサフィックス、変換後のファイルの出力先のディレクトリを指定します。

$ bash convert-wp-to-zenn/index.sh --help
Usage: index.sh [OPTIONS] <input_markdown_file>

Options:
  -h, --help                  Show this help message and exit
  -s, --suffix SUFFIX         Specify the output file suffix (default: _conversion)
  -d, --output-dir DIRECTORY  Specify the output directory (default: same as input file)

ディレクトリツリー

ディレクトリツリーは以下のとおりです。元々はWordPressの書き方からからZenn記法に変換したかったという背景があるのでconvert-wp-to-zennとしています。お好みの名前でどうぞ。

tree
.
├── articles
│   └── <ここに記事を配置>
└── convert-wp-to-zenn
    ├── index.sh
    └── lib
        ├── converters.sh
        └── utils.sh

4 directories, 4 files

./convert-wp-to-zenn/index.sh

エントリーとなる./convert-wp-to-zenn/index.shでは以下の処理を行っています。

  • ライブラリのシェルスクリプトの読み込み
  • 引数のパース
  • 入力ファイルの検証
  • 出力ファイル名の設定
    • 必要に応じて出力先ディレクトリの作成
  • Markdownへの変換
  • 変換後のファイルの文字数カウント
  • 使用方法の表示

コードは以下のとおりです。

convert-wp-to-zenn/index.sh
#!/usr/bin/env bash

set -euo pipefail
IFS=$'\n\t'

# 定数定義
readonly SCRIPT_NAME="${0##*/}"
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly LIB_DIR="${SCRIPT_DIR}/lib"
readonly DEFAULT_OUTPUT_SUFFIX="_conversion"

# ライブラリの読み込み
for lib in "${LIB_DIR}"/*.sh; do
  # shellcheck source=./lib/utils.sh
  # shellcheck source=./lib/converters.sh
  source "$lib"
done

# メイン処理
main() {
  local input_file
  local output_file
  local output_suffix="$DEFAULT_OUTPUT_SUFFIX"
  local output_dir=""

  parse_arguments "$@"
  validate_input_file "$input_file"
  setup_output_file "$input_file" "$output_suffix" "$output_dir"

  log_info "Starting conversion process for $input_file..."

  if ! convert_markdown "$input_file" "$output_file"; then
    die "Conversion process failed for $input_file"
  fi

  local char_count
  char_count=$(get_char_count "$output_file")

  log_info "Conversion completed successfully."
  log_info "Output file: $output_file"
  log_info "Character count: $char_count"
}

# 引数のパース
parse_arguments() {
  while [[ $# -gt 0 ]]; do
    case "$1" in
    -h | --help)
      usage
      exit 0
      ;;
    -s | --suffix)
      if [[ -n "$2" ]]; then
        output_suffix="$2"
        shift 2
      else
        die "Error: Argument for $1 is missing"
      fi
      ;;
    -d | --output-dir)
      if [[ -n "$2" ]]; then
        output_dir="$2"
        shift 2
      else
        die "Error: Argument for $1 is missing"
      fi
      ;;
    -*)
      die "Unknown option: $1"
      ;;
    *)
      if [[ -z ${input_file:-} ]]; then
        input_file="$1"
      else
        die "Error: Multiple input files specified"
      fi
      shift
      ;;
    esac
  done

  if [[ -z ${input_file:-} ]]; then
    usage
    die "Error: Input file not specified"
  fi
}

# 使用方法の表示
usage() {
  cat <<EOF
Usage: $SCRIPT_NAME [OPTIONS] <input_markdown_file>

Options:
  -h, --help                  Show this help message and exit
  -s, --suffix SUFFIX         Specify the output file suffix (default: $DEFAULT_OUTPUT_SUFFIX)
  -d, --output-dir DIRECTORY  Specify the output directory (default: same as input file)

EOF
}

# 入力ファイル検証
validate_input_file() {
  local file="$1"
  if ! [[ -f "$file" && -r "$file" ]]; then
    die "Input file not found or not readable: $file"
  fi
}

# 出力ファイル名の設定
setup_output_file() {
  local input="$1"
  local suffix="$2"
  local dir="$3"
  local input_dir
  local input_filename

  input_dir=$(dirname "$input")
  input_filename=$(basename "$input")

  if [[ -z "$dir" ]]; then
    dir="$input_dir"
  fi

  output_file="${dir}/${input_filename%.*}${suffix}.md"

  # 出力ディレクトリが存在しない場合は作成
  if [[ ! -d "$dir" ]]; then
    mkdir -p "$dir" || die "Failed to create output directory: $dir"
  fi

  # 既に同名のファイルがある場合は警告
  if [[ -e "$output_file" ]]; then
    log_warn "Output file already exists and will be overwritten: $output_file"
  fi
}

# 文字数のカウント
get_char_count() {
  local file="$1"
  wc -m <"$file" | tr -d '[:space:]'
}

# スクリプトが直接実行された場合のみメイン処理を実行
# source で呼び出された時に実行されないように
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
  main "$@"
fi

./convert-wp-to-zenn/lib/utils.sh

主にログ出力を行う./convert-wp-to-zenn/lib/utils.shは以下のとおりです。

./convert-wp-to-zenn/lib/utils.sh
#!/usr/bin/env bash

# 定数定義
readonly ANSI_RED='\033[0;31m'
readonly ANSI_GREEN='\033[0;32m'
readonly ANSI_YELLOW='\033[0;33m'
readonly ANSI_RESET='\033[0m'

# ログレベルの定義
declare -rA LOG_LEVELS=(
  [DEBUG]=0
  [INFO]=1
  [WARN]=2
  [ERROR]=3
)

# デフォルトのログレベルを設定
: "${LOG_LEVEL:=INFO}"

# ログ出力関数
log() {
  local -r level="$1"
  local -r message="$2"
  local -r timestamp="$(date '+%Y-%m-%d %H:%M:%S')"

  if [[ ! -v "LOG_LEVELS[$level]" ]]; then
    printf "Invalid log level: %s\n" "$level" >&2
    return 1
  fi

  if [[ ${LOG_LEVELS[$level]} -ge ${LOG_LEVELS[$LOG_LEVEL]} ]]; then
    local color
    case "$level" in
    DEBUG) color="$ANSI_RESET" ;;
    INFO) color="$ANSI_GREEN" ;;
    WARN) color="$ANSI_YELLOW" ;;
    ERROR) color="$ANSI_RED" ;;
    esac
    printf "${color}[%s] [%s] %s${ANSI_RESET}\n" "$timestamp" "$level" "$message" >&2
  fi
}

# ログレベルごとのログ出力関数のエイリアス
log_debug() { log DEBUG "$1"; }
log_info() { log INFO "$1"; }
log_warn() { log WARN "$1"; }
log_error() { log ERROR "$1"; }

# エラー終了時の処理
die() {
  log_error "$1"
  exit 1
}

./convert-wp-to-zenn/lib/converters.sh

実際のMarkdown記法に変換する処理を行う./convert-wp-to-zenn/lib/converters.shでは以下を行っています。

  • はてなブログカードのiframeの変換
  • aタグの変換
  • imgタグの変換
  • pタブのalertクラスの変換

なお、一行単位で処理を行っているので処理対象が複数行に跨る場合は処理はできません。

コードは以下のとおりです。

./convert-wp-to-zenn/lib/converters.sh
#!/usr/bin/env bash

set -euo pipefail
IFS=$'\n\t'

# 変換処理の実行
convert_markdown() {
  local -r input_file="$1"
  local -r output_file="$2"

  {
    convert_hatenablogcard <"$input_file" |
      convert_a_tags |
      convert_img_tags |
      convert_alert_class >"$output_file"
  } || die "Conversion process failed"
}

# hatenablogcard iframeの変換
convert_hatenablogcard() {
  sed -E 's/<iframe class="hatenablogcard"[^>]*src="[^?]*\?url=([^"]*)"[^>]*><\/iframe>/\1/'
}

# aタグの変換
convert_a_tags() {
  sed -E 's/<a ([^>]*href="([^"]*)"[^>]*)>([^<]*)<\/a>/[\3](\2)/g'
}

# imgタグの変換
convert_img_tags() {
  awk '
    {
      while (match($0, /<img [^>]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*>/)) {
        src = substr($0, RSTART, RLENGTH)
        sub(/.*src="/, "", src)
        sub(/".*/, "", src)
        alt = substr($0, RSTART, RLENGTH)
        sub(/.*alt="/, "", alt)
        sub(/".*/, "", alt)
        replacement = "![" alt "](" src ")"
        $0 = substr($0, 1, RSTART-1) replacement substr($0, RSTART+RLENGTH)
      }
      print
    }
    '
}

# alertクラスの変換
convert_alert_class() {
  sed -E 's/<p class="alert">(.*)<\/p>/:::message\n\1\n:::/'
}

この記事のタイトルで紹介しているaタグとimgタグの変換を行っている箇所についてはもう少し補足をします。

aタグはsedを使用して置換します。

パターンは<a ([^>]*href="([^"]*)"[^>]*)>([^<]*)<\/a>です。いくつかのチャンクで分割して説明します。

  • <a : <a とaタグの先頭とマッチ
  • ([^>]*href="([^"]*)"[^>]*) : href属性を含むタグの属性部分にマッチ
    • [^>]* : 0回以上の>以外の文字
    • href="([^"]*)" : href属性とその値とマッチ (([^"]*)でURL部分を取得する)
    • [^>]* : 0回以上の>以外の文字
  • > : aタグの末尾にマッチ
  • ([^<]*) : タグの内容 = アンカーテキストを取得
  • <\/a> : aタグの終了タグにマッチ

置換パターンは[\3](\2)です。それぞれMarkdownの以下を示しています。

  • [\3]: アンカーテキスト
  • (\2): URL

imgタグはawkを使用して置換します。コメントで補足しました。

awk '
  {
    # 各行($0)に対して、想定しているimgタグにマッチするものを探す
    while (match($0, /<img [^>]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*>/)) {
      # マッチしたimgタグ全体を取得
      src = substr($0, RSTART, RLENGTH)

      # src属性の値だけを取得
      sub(/.*src="/, "", src)
      sub(/".*/, "", src)

      # マッチしたimgタグ全体を取得
      alt = substr($0, RSTART, RLENGTH)

      # alt属性の値だけを取得
      sub(/.*alt="/, "", alt)
      sub(/".*/, "", alt)

      # Markdown形式に組み立て
      replacement = "![" alt "](" src ")"
      $0 = substr($0, 1, RSTART-1) replacement substr($0, RSTART+RLENGTH)
    }

    # 出力
    print
  }
'

実行してみる

以下のようなファイルを用意しました。

test.txt
<!-- はてなブログカードのiframeの変換  -->

<iframe class="hatenablogcard" src="https://hatenablog-parts.com/embed?url=https://aws.amazon.com/jp/blogs/news/new-aws-public-ipv4-address-charge-public-ip-insights/" width="680" height="150" frameborder="0" scrolling="no"></iframe>

<iframe class="hatenablogcard" frameborder="0" src="https://hatenablog-parts.com/embed?url=https://dev.classmethod.jp/articles/amazon-fsx-netapp-ontap-bluexp-flexcache-volume/" scrolling="no"></iframe>

<!-- aタグの変換  -->

こんにちは、のんピ(<a href="https://twitter.com/non____97" target="_blank" rel="noreferrer">@non____97</a>)です。

こんにちは、のんピ2(<a target="_blank" href="https://twitter.com/non____97" >ブログ狂中年卍</a>)です。

[DevelopersIO](https://dev.classmethod.jp/)

<!-- imgタグの変換 -->

<img src="https://images.ctfassets.net/ct0aopd36mqt/wp-thumbnail-dcbaa2123fec72b4fafe12edd4285aaf/178121bca8a805d8af9e86e4e4bbe732/aws-cloudformation" alt="CloudFormation"/>

<img alt="CloudFormation" width="651" height="451" src="https://images.ctfassets.net/ct0aopd36mqt/wp-thumbnail-dcbaa2123fec72b4fafe12edd4285aaf/178121bca8a805d8af9e86e4e4bbe732/aws-cloudformation"/>

<!-- pタブのalertクラスの変換 -->

<p class="alert">アラートです。 これは。</p>

<p class="alert">アラートです。 これは2。</p>

このファイルに対して処理をかけます。

$ bash convert-wp-to-zenn/index.sh articles/test.txt
[2024-07-14 14:33:30] [INFO] Starting conversion process for articles/test.txt...
[2024-07-14 14:33:30] [INFO] Conversion completed successfully.
[2024-07-14 14:33:30] [INFO] Output file: articles/test_conversion.md
[2024-07-14 14:33:30] [INFO] Character count: 855

出力されたファイルは以下のとおりです。

articles/test_conversion.md
<!-- はてなブログカードのiframeの変換  -->

https://aws.amazon.com/jp/blogs/news/new-aws-public-ipv4-address-charge-public-ip-insights/

https://dev.classmethod.jp/articles/amazon-fsx-netapp-ontap-bluexp-flexcache-volume/

<!-- aタグの変換  -->

こんにちは、のんピ([@non____97](https://twitter.com/non____97))です。

こんにちは、のんピ2([ブログ狂中年卍](https://twitter.com/non____97))です。

[DevelopersIO](https://dev.classmethod.jp/)

<!-- imgタグの変換 -->

![CloudFormation](https://images.ctfassets.net/ct0aopd36mqt/wp-thumbnail-dcbaa2123fec72b4fafe12edd4285aaf/178121bca8a805d8af9e86e4e4bbe732/aws-cloudformation)

<img alt="CloudFormation" width="651" height="451" src="https://images.ctfassets.net/ct0aopd36mqt/wp-thumbnail-dcbaa2123fec72b4fafe12edd4285aaf/178121bca8a805d8af9e86e4e4bbe732/aws-cloudformation"/>

<!-- pタブのalertクラスの変換 -->

:::message
アラートです。 これは。
:::

:::message
アラートです。 これは2。
:::

意図したとおりに変換されていますね。2つ目のimgタグが変換されていないのは属性の順番がawkのパターンとマッチしていないためです。

出力先のディレクトリパスとサフィックスを指定して2回実行します。

$ bash convert-wp-to-zenn/index.sh articles/test.txt -d dst -s _suffix
[2024-07-14 14:36:17] [INFO] Starting conversion process for articles/test.txt...
[2024-07-14 14:36:17] [INFO] Conversion completed successfully.
[2024-07-14 14:36:17] [INFO] Output file: dst/test_suffix.md
[2024-07-14 14:36:17] [INFO] Character count: 855

$ ls dst/
test_suffix.md

$ bash convert-wp-to-zenn/index.sh articles/test.txt -d dst -s _suffix
[2024-07-14 14:36:37] [WARN] Output file already exists and will be overwritten: dst/test_suffix.md
[2024-07-14 14:36:37] [INFO] Starting conversion process for articles/test.txt...
[2024-07-14 14:36:37] [INFO] Conversion completed successfully.
[2024-07-14 14:36:37] [INFO] Output file: dst/test_suffix.md
[2024-07-14 14:36:37] [INFO] Character count: 855

正常に実行されていますね。警告も表示されます。

シェルスクリプトで頑張りたい時に

HTMLのaタグとimgタグをMarkdown記法に変換するシェルスクリプトを書いてみました。

シェルスクリプトで頑張りたい時に参考にしてみてください。

この記事が誰かの助けになれば幸いです。

以上、AWS事業本部 コンサルティング部の のんピ(@non____97)でした!

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.